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}