Skip to main content

hpr_format/
container.rs

1//! The `.hprz` container: a design and the files that go with it, such as flight logs, results
2//! and photographs, in one zip archive ([ADR-112][adr-112]).
3//!
4//! The archive holds the design as its first entry, [`DESIGN_ENTRY`], written exactly as a `.hpr`
5//! file ([`to_json`]), so unzipping a container gives a `.hpr` any reader of the
6//! format takes. Every other entry is an attachment, kept byte for byte under its name, in order.
7//! Every entry is deflated and dated 1980-01-01, zip's zero date, so with one build of this crate a
8//! container's bytes depend only on what it holds. (Another deflate implementation, chosen by a
9//! program's features, can compress the same files to other bytes, which read back the same.)
10//!
11//! An attachment's name is a relative path with `/` between folders, so an archive can't place a
12//! file outside the folder it is unpacked into ([`check_name`]). A container holds at most
13//! [`MAX_UNPACKED_BYTES`], unpacked. Reading is held to the same rules as writing, so a hostile
14//! archive is refused with its reason rather than filling memory.
15//!
16//! ```
17//! use hpr_format::DesignFile;
18//! use hpr_format::container::{self, Entry, Hprz};
19//!
20//! let bytes = include_bytes!("../../../validation/fixtures/ork/loft-demo/demo-stable.ork");
21//! let design = DesignFile::from_ork(bytes)?.value;
22//! let log = Entry::new("logs/first-flight.csv", b"time_s,altitude_m\n0,0\n".to_vec());
23//! let hprz = Hprz::new(design, vec![log]);
24//! let written = container::write(&hprz)?;
25//! let read = container::read(&written)?;
26//! assert_eq!(read.value, hprz);
27//! # Ok::<(), Box<dyn std::error::Error>>(())
28//! ```
29//!
30//! [adr-112]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-112-m33b-the-hprz-container-and-migrations-2026-09-29
31
32use std::collections::BTreeMap;
33use std::io::{Cursor, Read, Write};
34use std::ops::Bound;
35
36use unicode_normalization::UnicodeNormalization;
37
38use crate::{DesignFile, FormatError, Opened, read_json, shortened, to_json};
39
40/// The name of the design's entry, the first in every container this crate writes.
41pub const DESIGN_ENTRY: &str = "design.hpr";
42
43/// The most a container holds, unpacked, over all its entries, the design's included: 256 MiB.
44/// [`write`](fn@write) refuses more, and [`read`] decompresses no more.
45///
46/// A deflate stream can expand by about a thousand to one, so a small archive could otherwise ask
47/// for more memory than a machine has. Use [`read_within`] to read with another limit.
48pub const MAX_UNPACKED_BYTES: u64 = 256 * 1024 * 1024;
49
50/// The longest part of an attachment's name, between `/`s, in bytes: what common file systems
51/// take for one file's or folder's name.
52pub const MAX_PART_BYTES: usize = 255;
53
54/// A design and its attachments.
55#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
56#[non_exhaustive]
57pub struct Hprz {
58    /// The design.
59    pub design: DesignFile,
60    /// The files that go with it, in the order the archive holds them.
61    pub attachments: Vec<Entry>,
62}
63
64impl Hprz {
65    /// A container of `design` and `attachments`.
66    pub fn new(design: DesignFile, attachments: Vec<Entry>) -> Self {
67        Self {
68            design,
69            attachments,
70        }
71    }
72}
73
74/// A file in a container: its name, a relative path such as `logs/flight-1.csv`, and its bytes.
75#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
76#[non_exhaustive]
77pub struct Entry {
78    /// Its name in the archive, with `/` between folders.
79    pub name: String,
80    /// Its contents.
81    pub bytes: Vec<u8>,
82}
83
84impl Entry {
85    /// The file `name` holding `bytes`.
86    pub fn new(name: impl Into<String>, bytes: Vec<u8>) -> Self {
87        Self {
88            name: name.into(),
89            bytes,
90        }
91    }
92}
93
94/// Whether `name` can name an attachment, and why not if it can't.
95///
96/// A name is a relative path: folders and a file name joined by `/`, none of them empty, `.` or
97/// `..`. It holds no `\`, no `:` and no control character, since each can reach outside the
98/// folder a container is unpacked into on some system. No part is longer than [`MAX_PART_BYTES`],
99/// none ends in `.` or a space, and none is
100/// a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `CONIN$`, `CONOUT$`, `COM0` to `COM9`,
101/// `LPT0` to `LPT9`, `COM` or `LPT` with `¹`, `²` or `³`, with or without an extension or spaces
102/// before it), which Windows would drop or open as a device. And neither it nor its first folder
103/// is [`DESIGN_ENTRY`], ignoring case and how an accent is spelled.
104///
105/// # Errors
106///
107/// [`FormatError::Container`], with the reason.
108pub fn check_name(name: &str) -> Result<(), FormatError> {
109    let quoted = shortened(name);
110    let refused = |why: String| Err(FormatError::Container(why));
111    if name.is_empty() {
112        return refused("an attachment's name is empty".to_owned());
113    }
114    if name
115        .split('/')
116        .next()
117        .is_some_and(|first| compared(first) == DESIGN_ENTRY)
118    {
119        return refused(format!(
120            "an attachment is named {quoted:?}, which is the design's entry or a folder of that \
121             name"
122        ));
123    }
124    if let Some(bad) = name
125        .chars()
126        .find(|c| *c == '\\' || *c == ':' || c.is_control())
127    {
128        return refused(format!(
129            "the attachment {quoted:?} has {bad:?} in its name, which a relative path with `/` \
130             between folders doesn't"
131        ));
132    }
133    if name
134        .split('/')
135        .any(|part| part.is_empty() || part == "." || part == "..")
136    {
137        return refused(format!(
138            "the attachment {quoted:?} is not a relative path: it starts or ends with `/`, or has \
139             an empty, `.` or `..` part"
140        ));
141    }
142    if name.split('/').any(|part| part.ends_with(['.', ' '])) {
143        return refused(format!(
144            "the attachment {quoted:?} has a part ending in `.` or a space, which Windows drops"
145        ));
146    }
147    if let Some(part) = name.split('/').find(|part| part.len() > MAX_PART_BYTES) {
148        return refused(format!(
149            "the attachment {quoted:?} has a part {:?} longer than {MAX_PART_BYTES} bytes, which \
150             file systems refuse",
151            shortened(part)
152        ));
153    }
154    if let Some(part) = name.split('/').find(|part| is_device(part)) {
155        return refused(format!(
156            "the attachment {quoted:?} has a part named {:?}, a Windows device's name",
157            shortened(part)
158        ));
159    }
160    Ok(())
161}
162
163/// Whether `part` of a path names a Windows device, with or without an extension, and with or
164/// without spaces before it, which Windows drops (Microsoft's "Naming files, paths, and
165/// namespaces").
166fn is_device(part: &str) -> bool {
167    let stem = part.split('.').next().unwrap_or(part).trim_end_matches(' ');
168    let stem = stem.to_ascii_uppercase();
169    let numbered = |prefix: &str| {
170        stem.strip_prefix(prefix).is_some_and(|rest| {
171            let mut chars = rest.chars();
172            matches!(chars.next(), Some('0'..='9' | '¹' | '²' | '³')) && chars.next().is_none()
173        })
174    };
175    matches!(
176        stem.as_str(),
177        "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
178    ) || numbered("COM")
179        || numbered("LPT")
180}
181
182/// The names of a container's attachments checked, each by [`check_name`]; no two the same,
183/// ignoring case and how an accent is spelled ([`compared`]), and none also a folder of another,
184/// since a file system that ignores case or spelling, or any file system, would unpack such a pair
185/// as one. The refusal names both, so two spellings that print alike can be told apart (`é` and
186/// `e\u{301}`).
187///
188/// Its memory is the names' once over: each name is looked up among the others as a folder, by
189/// the first name at or after it and a `/`, rather than by listing every name's folders, which a
190/// name of many short folders would multiply.
191fn check_names<'a>(names: impl Iterator<Item = &'a str> + Clone) -> Result<(), FormatError> {
192    let mut seen = BTreeMap::new();
193    for name in names.clone() {
194        check_name(name)?;
195        if let Some(first) = seen.insert(compared(name), name) {
196            return Err(FormatError::Container(if first == name {
197                format!("two attachments are named {:?}", shortened(name))
198            } else {
199                format!(
200                    "two attachments are named {:?} and {:?}, one name ignoring case and how an \
201                     accent is spelled",
202                    shortened(first),
203                    shortened(name)
204                )
205            }));
206        }
207    }
208    for name in names {
209        let folder = format!("{}/", compared(name));
210        let next = seen
211            .range::<str, _>((Bound::Included(folder.as_str()), Bound::Unbounded))
212            .next();
213        if let Some((_, other)) = next.filter(|(key, _)| key.starts_with(&folder)) {
214            return Err(FormatError::Container(format!(
215                "the attachment {:?} is also the folder of another, {:?}",
216                shortened(name),
217                shortened(other)
218            )));
219        }
220    }
221    Ok(())
222}
223
224/// `name` as a file system that ignores case and how an accent is spelled compares it: Unicode's
225/// canonical caseless match (The Unicode Standard, section 3.13, "Default Case Algorithms",
226/// definition D145): the canonical decomposition (NFD) of the folded canonical decomposition, with
227/// [`folded`]'s case folding in place of the standard's.
228///
229/// The decomposition is what macOS's file systems compare: `é` written as one letter, U+00E9, and
230/// as `e` and a combining acute accent, U+0301, are one name there, as are two orders of the marks
231/// on one letter. It comes before the folding because folding can turn a mark into a letter, so it
232/// must see the marks in their canonical order: Greek `α` with a combining acute and a combining
233/// ypogegrammeni (U+0345), in either order, would otherwise fold to two names. It comes after as
234/// the standard has it, so the compared form is normalized whatever a case mapping gives; with the
235/// mappings Rust has now, no character followed by marks needs that last step.
236///
237/// Each step's output is a few times its input's length at most, and canonical ordering sorts each
238/// run of marks, a stable sort of `n log n` steps, so even a name of one long run of marks costs
239/// about its length.
240fn compared(name: &str) -> String {
241    folded(&name.nfd().collect::<String>()).nfd().collect()
242}
243
244/// `name` with its case folded as a file system that ignores case compares it: upper case then
245/// lower, so that letters with one capital but two small forms fold as one, such as `σ` and `ς`,
246/// `s` and `ſ`, or `μ` and the micro sign `µ`. Unicode's full mapping also folds some letters
247/// into two (`ß` into `ss`), so a pair such as `straße` and `strasse` is refused too, though
248/// Windows would keep both: the rule errs towards refusing.
249fn folded(name: &str) -> String {
250    name.to_uppercase().to_lowercase()
251}
252
253/// The container's bytes: the design as [`DESIGN_ENTRY`], then each attachment, in order.
254///
255/// # Errors
256///
257/// What [`to_json`] refuses in the design, and [`FormatError::Container`] for an attachment's name
258/// [`check_name`] refuses, two of one name (ignoring case and how an accent is spelled), a name that
259/// is also another's folder, or more than [`MAX_UNPACKED_BYTES`] in all.
260pub fn write(hprz: &Hprz) -> Result<Vec<u8>, FormatError> {
261    let text = to_json(&hprz.design)?;
262    check_names(hprz.attachments.iter().map(|entry| entry.name.as_str()))?;
263    let unpacked = hprz
264        .attachments
265        .iter()
266        .fold(text.len() as u64, |sum, entry| {
267            sum.saturating_add(entry.bytes.len() as u64)
268        });
269    within_the_limit(unpacked)?;
270    let zip = |error: &dyn std::fmt::Display| FormatError::Container(error.to_string());
271    // `SimpleFileOptions::DEFAULT` and a named date rather than `default()`, which reads the clock
272    // when zip's `time` feature is on, and panics on `wasm32-unknown-unknown` (as `hpr-io` writes
273    // a `.ork`).
274    let options = zip::write::SimpleFileOptions::DEFAULT
275        .compression_method(zip::CompressionMethod::Deflated)
276        .last_modified_time(zip::DateTime::DEFAULT);
277    let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
278    archive
279        .start_file(DESIGN_ENTRY, options)
280        .map_err(|e| zip(&e))?;
281    archive.write_all(text.as_bytes()).map_err(|e| zip(&e))?;
282    for entry in &hprz.attachments {
283        archive
284            .start_file(entry.name.as_str(), options)
285            .map_err(|e| zip(&e))?;
286        archive.write_all(&entry.bytes).map_err(|e| zip(&e))?;
287    }
288    Ok(archive.finish().map_err(|e| zip(&e))?.into_inner())
289}
290
291/// Refuses `unpacked` bytes past [`MAX_UNPACKED_BYTES`].
292fn within_the_limit(unpacked: u64) -> Result<(), FormatError> {
293    if unpacked > MAX_UNPACKED_BYTES {
294        return Err(FormatError::Container(format!(
295            "it would hold {unpacked} bytes unpacked, and a container holds at most \
296             {MAX_UNPACKED_BYTES} (256 MiB)"
297        )));
298    }
299    Ok(())
300}
301
302/// How many records a zip archive's central directory holds from `start`: each begins with the
303/// signature `PK\x01\x02` and is 46 bytes, then its name, extra field and comment, whose lengths
304/// are the little-endian `u16`s at 28, 30 and 32 (APPNOTE.TXT 6.3.10, section 4.3.12).
305fn central_records(bytes: &[u8], start: usize) -> usize {
306    const SIGNATURE: &[u8] = b"PK\x01\x02";
307    const FIXED: usize = 46;
308    let u16_at = |at: usize| usize::from(u16::from_le_bytes([bytes[at], bytes[at + 1]]));
309    let mut count = 0;
310    let mut at = start;
311    while let Some(record) = bytes.get(at..at.saturating_add(FIXED))
312        && record.starts_with(SIGNATURE)
313    {
314        count += 1;
315        // In range: the fixed part was just read.
316        at += FIXED + u16_at(at + 28) + u16_at(at + 30) + u16_at(at + 32);
317    }
318    count
319}
320
321/// Reads a container, taking a design of an older version to the current one.
322///
323/// [`read_within`] with [`MAX_UNPACKED_BYTES`].
324///
325/// # Errors
326///
327/// As [`read_within`].
328pub fn read(bytes: &[u8]) -> Result<Opened<Hprz>, FormatError> {
329    read_within(bytes, MAX_UNPACKED_BYTES)
330}
331
332/// Reads a container, decompressing at most `max_unpacked_bytes` out of it, and says which version
333/// its design was written in.
334///
335/// The design is read as a `.hpr` is ([`read_json`]). A folder's own entry, which some zip tools
336/// write, holds nothing and is passed over; every other entry is an attachment, held to the rules
337/// [`write`](fn@write) holds it to. Every name is checked before anything is decompressed.
338///
339/// # Errors
340///
341/// [`FormatError::Container`] when the bytes are not a zip archive, an entry can't be read, the
342/// central directory holds more entries than the zip reader keeps (two of one name), an entry is a
343/// symbolic link or its name isn't marked as UTF-8, an attachment's name is refused, the archive
344/// would decompress to more than `max_unpacked_bytes`, or it holds no [`DESIGN_ENTRY`] or the
345/// design isn't UTF-8; and what [`read_json`] refuses in the design.
346pub fn read_within(bytes: &[u8], max_unpacked_bytes: u64) -> Result<Opened<Hprz>, FormatError> {
347    let budget = max_unpacked_bytes;
348    if !matches!(bytes, [b'P', b'K', 3, 4, ..] | [b'P', b'K', 5, 6, ..]) {
349        return Err(FormatError::Container(
350            "a .hprz is a zip archive, and these bytes don't start as one".to_owned(),
351        ));
352    }
353    let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
354        .map_err(|error| FormatError::Container(error.to_string()))?;
355    // The zip reader keeps one entry of each name and drops the others without a word, so the
356    // central directory's records are counted here: more records than entries means two share a
357    // name, and one of them would be lost.
358    let start = usize::try_from(archive.central_directory_start()).unwrap_or(usize::MAX);
359    let records = central_records(bytes, start);
360    if records != archive.len() {
361        return Err(FormatError::Container(format!(
362            "its central directory holds {records} entries, and the zip reader keeps {}: two \
363             have the same name, and one would be lost, or the directory is damaged",
364            archive.len()
365        )));
366    }
367    // Every name, before anything is decompressed.
368    let mut names = Vec::new();
369    for index in 0..archive.len() {
370        let entry = archive.by_index_raw(index).map_err(|error| {
371            FormatError::Container(format!("entry {index} can't be opened: {error}"))
372        })?;
373        let name = entry.name();
374        if entry.is_dir() {
375            // A folder's entry holds nothing: one that holds bytes, or that the zip reader takes
376            // for a folder by a `\` at its end, would be lost.
377            if entry.size() > 0 || name.ends_with('\\') {
378                return Err(FormatError::Container(format!(
379                    "{:?} is a folder's entry that is not an empty folder",
380                    shortened(name)
381                )));
382            }
383            continue;
384        }
385        if entry.name_raw() != name.as_bytes() {
386            return Err(FormatError::Container(format!(
387                "the name of {:?} isn't marked as UTF-8, as a name beyond plain ASCII must be \
388                 (some zip tools leave the mark off)",
389                shortened(name)
390            )));
391        }
392        if entry.is_symlink() {
393            return Err(FormatError::Container(format!(
394                "{:?} is a symbolic link, which a container doesn't hold",
395                shortened(name)
396            )));
397        }
398        names.push(name.to_owned());
399    }
400    // The first entry of the design's name is the design, as below; the central directory's count
401    // has already refused a second.
402    let Some(at) = names.iter().position(|name| name == DESIGN_ENTRY) else {
403        return Err(FormatError::Container(format!(
404            "it holds no {DESIGN_ENTRY:?}, the design"
405        )));
406    };
407    names.remove(at);
408    check_names(names.iter().map(String::as_str))?;
409    let mut design = None;
410    let mut attachments = Vec::new();
411    let mut used = 0u64;
412    for index in 0..archive.len() {
413        let mut entry = archive.by_index(index).map_err(|error| {
414            FormatError::Container(format!("entry {index} can't be opened: {error}"))
415        })?;
416        if entry.is_dir() {
417            continue;
418        }
419        let name = entry.name().to_owned();
420        let left = budget.saturating_sub(used);
421        let mut content = Vec::new();
422        // One byte past what is left, so that an entry filling the budget exactly is still known
423        // to have overrun it.
424        entry
425            .by_ref()
426            .take(left.saturating_add(1))
427            .read_to_end(&mut content)
428            .map_err(|error| {
429                FormatError::Container(format!(
430                    "{:?} can't be decompressed: {error}",
431                    shortened(&name)
432                ))
433            })?;
434        if content.len() as u64 > left {
435            return Err(FormatError::Container(format!(
436                "it decompresses to more than {budget} bytes, the most one container may"
437            )));
438        }
439        used += content.len() as u64;
440        if name == DESIGN_ENTRY && design.is_none() {
441            design = Some(content);
442        } else {
443            attachments.push(Entry::new(name, content));
444        }
445    }
446    let design = design.ok_or_else(|| {
447        FormatError::Container(format!("it holds no {DESIGN_ENTRY:?}, the design"))
448    })?;
449    let text = String::from_utf8(design)
450        .map_err(|_| FormatError::Container(format!("its {DESIGN_ENTRY:?} is not UTF-8 text")))?;
451    let opened = read_json(&text)?;
452    Ok(Opened {
453        value: Hprz::new(opened.value, attachments),
454        written_as: opened.written_as,
455    })
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    /// The limit itself is held, and one byte more is not: the writer's test refuses one past it
463    /// without compressing 256 MiB to show the limit is taken.
464    #[test]
465    fn a_container_may_hold_the_limit_and_no_more() {
466        assert!(within_the_limit(MAX_UNPACKED_BYTES).is_ok());
467        assert!(within_the_limit(MAX_UNPACKED_BYTES + 1).is_err());
468    }
469}