Skip to main content

hpr_io/ork/
container.rs

1//! The three packages a `.ork` file arrives in, told apart by their first bytes.
2//!
3//! OpenRocket writes a zip archive holding an entry called `rocket.ork`, which is the design as
4//! XML, beside whatever else the design carries (motor files, a preview image). Older versions
5//! wrote the XML gzipped, and a plain XML file is accepted too. The format documentation calls
6//! all three `.ork`. Nothing here touches the filesystem: the caller hands over the bytes.
7
8use std::io::{Cursor, Read};
9
10use super::error::OrkError;
11use super::warning::{Imported, Warning, WarningKind};
12
13/// The three ways a `.ork` file packages its design document.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
15#[serde(rename_all = "snake_case")]
16#[non_exhaustive]
17pub enum Container {
18    /// A zip archive; the design is the `rocket.ork` entry. What OpenRocket writes today.
19    Zip,
20    /// A single gzip stream whose contents are the design document.
21    Gzip,
22    /// The design document itself, uncompressed.
23    Xml,
24}
25
26impl Container {
27    /// The name the format documentation gives this container.
28    pub fn as_str(self) -> &'static str {
29        match self {
30            Self::Zip => "zip",
31            Self::Gzip => "gzip",
32            Self::Xml => "xml",
33        }
34    }
35
36    /// Names the container `bytes` are packed in, from its first bytes alone.
37    ///
38    /// This is [Loft lesson L56: containers are told apart by their magic bytes, and malformed
39    /// input must give an error rather than crash][lessons].
40    ///
41    /// [lessons]: https://github.com/nrdptel/hpr-sim/blob/main/docs/research/loft-lessons.md
42    ///
43    /// Zip archives begin `PK` followed by `\x03\x04` (a local file header), `\x05\x06` (an empty
44    /// archive) or `\x07\x08` (a spanned one); gzip members begin `\x1f\x8b`. Anything else is
45    /// taken to be XML if, once a byte-order mark and leading whitespace are passed, it begins
46    /// with `<`. Returns `None` when it is none of the three.
47    pub fn sniff(bytes: &[u8]) -> Option<Self> {
48        match bytes {
49            [b'P', b'K', 3, 4, ..] | [b'P', b'K', 5, 6, ..] | [b'P', b'K', 7, 8, ..] => {
50                Some(Self::Zip)
51            }
52            [0x1f, 0x8b, ..] => Some(Self::Gzip),
53            _ if xml_body(bytes).starts_with(b"<") => Some(Self::Xml),
54            _ => None,
55        }
56    }
57}
58
59/// An entry of a `.ork` archive that is not the design document, kept as it was stored.
60///
61/// A `.ork` may carry the thrust curves its motors were flown with (`thrustcurves/*.rse`, schema
62/// 1.11), a `preview.png`, or lookup tables. They are held here byte for byte so that later
63/// milestones can read them and an export can put them back.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Attachment {
66    /// The entry's name inside the archive, as stored, with `/` between directories.
67    pub name: String,
68    /// The entry's contents, decompressed.
69    pub bytes: Vec<u8>,
70}
71
72/// A `.ork` file unpacked: the design document as text, and everything else that came with it.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Unpacked {
75    /// Which of the three containers it was in.
76    pub container: Container,
77    /// The name of the archive entry the design came from, when it came from an archive.
78    pub design_entry: Option<String>,
79    /// The design document, as XML text.
80    pub design: String,
81    /// Every other entry of the archive, in archive order. Empty for gzip and raw XML.
82    pub attachments: Vec<Attachment>,
83}
84
85/// Unpacks `bytes`, whichever of the three containers they are in.
86///
87/// Fails only when the bytes are not a `.ork` file at all, when the container itself is damaged,
88/// or when no entry inside it can be the design. An archive entry that cannot be decompressed is
89/// skipped with a warning, unless it is the design.
90pub fn unpack(bytes: &[u8]) -> Result<Imported<Unpacked>, OrkError> {
91    unpack_within(bytes, MAX_UNPACKED_BYTES)
92}
93
94/// Unpacks `bytes`, decompressing at most `budget` bytes out of the archive.
95///
96/// [`unpack`] is this with [`MAX_UNPACKED_BYTES`]. An entry that would pass the budget is left out
97/// with a [`WarningKind::Skipped`] warning; if that entry was the design, the read fails with
98/// [`OrkError::NoDesign`] rather than returning half a file.
99pub fn unpack_within(bytes: &[u8], budget: u64) -> Result<Imported<Unpacked>, OrkError> {
100    match Container::sniff(bytes) {
101        Some(Container::Zip) => unpack_zip(bytes, budget),
102        Some(Container::Gzip) => {
103            let mut text = Vec::new();
104            flate2::read::GzDecoder::new(bytes)
105                .take(budget.saturating_add(1))
106                .read_to_end(&mut text)
107                .map_err(|error| OrkError::Gzip {
108                    reason: error.to_string(),
109                })?;
110            if text.len() as u64 > budget {
111                return Err(OrkError::TooBig { limit: budget });
112            }
113            Ok(Imported::clean(Unpacked {
114                container: Container::Gzip,
115                design_entry: None,
116                design: utf8(text)?,
117                attachments: Vec::new(),
118            }))
119        }
120        Some(Container::Xml) => Ok(Imported::clean(Unpacked {
121            container: Container::Xml,
122            design_entry: None,
123            design: utf8(bytes.to_vec())?,
124            attachments: Vec::new(),
125        })),
126        None => Err(OrkError::UnknownContainer { head: head(bytes) }),
127    }
128}
129
130/// The name OpenRocket gives the design entry inside the archive.
131const DESIGN_ENTRY: &str = "rocket.ork";
132
133/// How many bytes [`unpack`] will decompress out of one archive, over all its entries.
134///
135/// A deflate stream can expand by about a thousand to one, so a small `.ork` could otherwise ask
136/// for more memory than a machine has, which aborts rather than returning the error [Loft lesson
137/// L56: malformed input must give an error rather than crash][lessons] asks for. The largest
138/// design in the reference corpus unpacks to under 6 MiB, so 256 MiB is room to spare. Use
139/// [`unpack_within`] to choose another.
140///
141/// [lessons]: https://github.com/nrdptel/hpr-sim/blob/main/docs/research/loft-lessons.md
142pub const MAX_UNPACKED_BYTES: u64 = 256 * 1024 * 1024;
143
144fn unpack_zip(bytes: &[u8], budget: u64) -> Result<Imported<Unpacked>, OrkError> {
145    let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|error| OrkError::Zip {
146        reason: error.to_string(),
147    })?;
148    let mut warnings = Vec::new();
149    let mut entries: Vec<Attachment> = Vec::new();
150    let mut used = 0u64;
151    for index in 0..archive.len() {
152        let mut entry = match archive.by_index(index) {
153            Ok(entry) => entry,
154            Err(error) => {
155                warnings.push(Warning::new(
156                    format!("entry {index}"),
157                    WarningKind::Skipped,
158                    format!("this entry could not be opened and was left out: {error}"),
159                ));
160                continue;
161            }
162        };
163        if entry.is_dir() {
164            continue;
165        }
166        let name = entry.name().to_owned();
167        let left = budget.saturating_sub(used);
168        let mut content = Vec::new();
169        // One byte past what is left, so that an entry filling the budget exactly is still known
170        // to have overrun it.
171        if let Err(error) = entry.by_ref().take(left + 1).read_to_end(&mut content) {
172            warnings.push(Warning::new(
173                name,
174                WarningKind::Skipped,
175                format!("this entry could not be decompressed and was left out: {error}"),
176            ));
177            continue;
178        }
179        if content.len() as u64 > left {
180            warnings.push(Warning::new(
181                name,
182                WarningKind::Skipped,
183                format!(
184                    "this entry was left out: unpacking it would pass the {budget}-byte limit on \
185                     what one archive may decompress to"
186                ),
187            ));
188            continue;
189        }
190        used += content.len() as u64;
191        entries.push(Attachment {
192            name,
193            bytes: content,
194        });
195    }
196
197    let chosen = choose_design(&entries, &mut warnings).ok_or(OrkError::NoDesign)?;
198    let design_bytes = entries.remove(chosen);
199    let design = utf8(design_bytes.bytes)?;
200    Ok(Imported {
201        value: Unpacked {
202            container: Container::Zip,
203            design_entry: Some(design_bytes.name),
204            design,
205            attachments: entries,
206        },
207        warnings,
208    })
209}
210
211/// Picks the entry that holds the design, preferring the documented name.
212fn choose_design(entries: &[Attachment], warnings: &mut Vec<Warning>) -> Option<usize> {
213    if let Some(index) = entries.iter().position(|e| e.name == DESIGN_ENTRY) {
214        return Some(index);
215    }
216    let candidates: Vec<usize> = entries
217        .iter()
218        .enumerate()
219        .filter(|(_, e)| {
220            let lower = e.name.to_ascii_lowercase();
221            lower.ends_with(".ork") || lower.ends_with(".xml")
222        })
223        .map(|(index, _)| index)
224        .collect();
225    let first = *candidates.first()?;
226    warnings.push(Warning::new(
227        entries[first].name.clone(),
228        WarningKind::Unusual,
229        if candidates.len() > 1 {
230            format!(
231                "the archive has no `{DESIGN_ENTRY}`; this is the first of {} entries that could \
232                 be the design, and the others are kept as attachments",
233                candidates.len()
234            )
235        } else {
236            format!("the archive has no `{DESIGN_ENTRY}`; this entry was read as the design")
237        },
238    ));
239    Some(first)
240}
241
242/// Passes a UTF-8 byte-order mark and any leading whitespace, so that sniffing sees the first tag.
243fn xml_body(bytes: &[u8]) -> &[u8] {
244    let rest = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes);
245    let start = rest
246        .iter()
247        .position(|byte| !byte.is_ascii_whitespace())
248        .unwrap_or(rest.len());
249    &rest[start..]
250}
251
252/// Takes the design's bytes by value: they may be tens of megabytes, and a second copy is one too
253/// many.
254fn utf8(bytes: Vec<u8>) -> Result<String, OrkError> {
255    let mut text = String::from_utf8(bytes).map_err(|_| OrkError::NotUtf8)?;
256    if text.starts_with('\u{feff}') {
257        text.remove(0);
258    }
259    Ok(text)
260}
261
262/// The first four bytes as hex, for the error that says what was seen instead of a `.ork`.
263fn head(bytes: &[u8]) -> String {
264    if bytes.is_empty() {
265        return "nothing (the file is empty)".to_owned();
266    }
267    bytes
268        .iter()
269        .take(4)
270        .map(|byte| format!("{byte:02x}"))
271        .collect::<Vec<_>>()
272        .join(" ")
273}