Skip to main content

opys_bundle/
lib.rs

1//! The bundle: a manifest and the blobs it names, as one file.
2//!
3//! It is a zip, and deliberately nothing more — `unzip -l` reads it:
4//!
5//! ```text
6//! opys.json        the head: the bundle's format, and its options
7//! manifest.json    the manifest, whole
8//! blobs/<sha256>   one entry per blob
9//! ```
10//!
11//! The head is about the bundle and the manifest is about the installation,
12//! and the two are kept apart for that reason. The head is the first entry
13//! and is stored uncompressed, so what a bundle is can be read with one seek,
14//! or off the front of the file by something that never parses a zip at all.
15//! It says the `format` and the `options` a player may set; whatever else is
16//! worth knowing about a bundle without decoding megabytes of manifest goes
17//! there.
18//!
19//! `manifest.json` is a [`Manifest`] as `opys-core` reads and writes it, and
20//! nothing else: the container adds no spelling of its own.
21
22use std::fs::File;
23use std::io::{self, Read, Seek, Write};
24
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27use zip::write::SimpleFileOptions;
28use zip::{CompressionMethod, DateTime, ZipArchive, ZipWriter};
29
30use opys_core::Manifest;
31
32mod blob;
33mod directory;
34mod localized;
35mod option;
36
37pub use blob::{blob_id, blob_id_of, BlobSource, Blobs};
38pub use directory::{directory_from_back, Back, Directory, Entry, EntryDecoder};
39pub use localized::{is_language_tag, Localized};
40pub use option::{Choice, Label, OptionDef, Options};
41
42/// The format this reader and writer speak. A bundle that says anything else
43/// is refused before another byte of it is interpreted.
44pub const BUNDLE_FORMAT: u32 = 1;
45
46const HEAD_ENTRY: &str = "opys.json";
47const MANIFEST_ENTRY: &str = "manifest.json";
48
49fn blob_entry(id: &str) -> String {
50    format!("blobs/{id}")
51}
52
53#[derive(Debug, thiserror::Error)]
54pub enum BundleError {
55    #[error("not a bundle: {0}")]
56    Zip(#[from] zip::result::ZipError),
57    #[error(transparent)]
58    Io(#[from] io::Error),
59    #[error("not a bundle: it has no `{0}`")]
60    MissingEntry(&'static str),
61    #[error("`{entry}` does not parse: {source}")]
62    Json {
63        entry: &'static str,
64        #[source]
65        source: serde_json::Error,
66    },
67    #[error(
68        "bundle format {found} is not one this reader knows (it reads format {BUNDLE_FORMAT})"
69    )]
70    Format { found: u64 },
71    #[error("the manifest names blob {0}, and nothing holds it")]
72    MissingBlob(String),
73    #[error("blob {id} does not hold what its name says: its bytes hash to {found}")]
74    BlobMismatch { id: String, found: String },
75    #[error("cannot read blob {id} from {path}: {source}")]
76    BlobFile {
77        id: String,
78        path: String,
79        #[source]
80        source: io::Error,
81    },
82}
83
84/// What a bundle says about itself, apart from the installation it carries.
85///
86/// A head that has been read is in [`BUNDLE_FORMAT`], since any other is
87/// refused; the field is kept because a head is also what gets written.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(try_from = "HeadWire", into = "HeadWire")]
90pub struct Head {
91    pub format: u32,
92    /// The language the options are written in: a plain string is in it,
93    /// and a translated one must hold it, so a launcher always has something
94    /// to fall back to. Needed only once some text is translated.
95    pub locale: Option<String>,
96    /// What whoever launches the bundle may choose: the variables and
97    /// features it leaves to them, and how to ask for each.
98    pub options: Options,
99}
100
101impl Head {
102    /// A head in the format this crate writes. Refuses a `locale` that is no
103    /// language tag, and a translated text with no `locale` or without it.
104    pub fn new(locale: Option<String>, options: Options) -> Result<Self, String> {
105        if let Some(bad) = locale.as_deref().filter(|tag| !is_language_tag(tag)) {
106            return Err(format!("the head's locale, `{bad}`, is not a language tag"));
107        }
108        for text in options.texts() {
109            let Localized::Translated(map) = text else {
110                continue;
111            };
112            let languages = || map.keys().cloned().collect::<Vec<_>>().join("`, `");
113            match &locale {
114                None => {
115                    return Err(format!(
116                        "a text is translated (`{}`), and the head names no locale to fall back to",
117                        languages()
118                    ))
119                }
120                Some(locale) if !map.contains_key(locale) => {
121                    return Err(format!(
122                        "a text is translated (`{}`) and not into the head's locale, `{locale}`",
123                        languages()
124                    ))
125                }
126                Some(_) => {}
127            }
128        }
129        Ok(Head {
130            format: BUNDLE_FORMAT,
131            locale,
132            options,
133        })
134    }
135}
136
137impl Default for Head {
138    fn default() -> Self {
139        Head {
140            format: BUNDLE_FORMAT,
141            locale: None,
142            options: Options::default(),
143        }
144    }
145}
146
147/// Not `deny_unknown_fields`: what a head says about its bundle may grow
148/// without a new format, since the format decides only whether the manifest
149/// beside it can be read.
150#[derive(Serialize, Deserialize)]
151pub(crate) struct HeadWire {
152    format: u32,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    locale: Option<String>,
155    #[serde(default, skip_serializing_if = "Options::is_empty")]
156    options: Options,
157}
158
159impl TryFrom<HeadWire> for Head {
160    type Error = String;
161
162    fn try_from(raw: HeadWire) -> Result<Self, Self::Error> {
163        let head = Head::new(raw.locale, raw.options)?;
164        Ok(Head {
165            format: raw.format,
166            ..head
167        })
168    }
169}
170
171impl From<Head> for HeadWire {
172    fn from(head: Head) -> Self {
173        HeadWire {
174            format: head.format,
175            locale: head.locale,
176            options: head.options,
177        }
178    }
179}
180
181/// Only the format, read first and as widely as a number goes: another
182/// format may spell the rest of the head in a way this reader would misreport
183/// as a parse error.
184///
185/// A reader reads one format. Any other number is refused, an older one as
186/// much as a newer: there is no migration and no lenient reading, because a
187/// half-understood manifest is one that installs or deletes the wrong files.
188/// Whoever holds an old bundle rebuilds it.
189#[derive(Deserialize)]
190struct FormatProbe {
191    format: u64,
192}
193
194fn parse_head(bytes: &[u8]) -> Result<Head, BundleError> {
195    let json = |source| BundleError::Json {
196        entry: HEAD_ENTRY,
197        source,
198    };
199    let probe: FormatProbe = serde_json::from_slice(bytes).map_err(json)?;
200    if probe.format != u64::from(BUNDLE_FORMAT) {
201        return Err(BundleError::Format {
202            found: probe.format,
203        });
204    }
205    serde_json::from_slice(bytes).map_err(json)
206}
207
208fn parse_manifest(bytes: &[u8]) -> Result<Manifest, BundleError> {
209    serde_json::from_slice(bytes).map_err(|source| BundleError::Json {
210        entry: MANIFEST_ENTRY,
211        source,
212    })
213}
214
215fn read_entry<R: Read + Seek>(
216    archive: &mut ZipArchive<R>,
217    name: &'static str,
218) -> Result<Vec<u8>, BundleError> {
219    let mut entry = match archive.by_name(name) {
220        Ok(entry) => entry,
221        Err(zip::result::ZipError::FileNotFound) => return Err(BundleError::MissingEntry(name)),
222        Err(other) => return Err(other.into()),
223    };
224    let mut bytes = Vec::with_capacity(entry.size() as usize);
225    entry.read_to_end(&mut bytes)?;
226    Ok(bytes)
227}
228
229/// Read a bundle's head and nothing else. Costs the zip's directory and a few
230/// bytes, whatever the bundle weighs.
231pub fn read_bundle_head<R: Read + Seek>(reader: R) -> Result<Head, BundleError> {
232    let mut archive = ZipArchive::new(reader)?;
233    parse_head(&read_entry(&mut archive, HEAD_ENTRY)?)
234}
235
236/// What the first bytes of a bundle give of its head.
237#[derive(Debug, Clone, PartialEq)]
238pub enum Front {
239    Head(Head),
240    /// The head ends past what was given: it takes this many bytes, counted
241    /// from the start of the file.
242    Needs(u64),
243}
244
245const LOCAL_HEADER: [u8; 4] = *b"PK\x03\x04";
246const LOCAL_HEADER_LEN: usize = 30;
247/// Set when an entry's sizes follow its data instead of preceding it.
248const SIZES_AFTER_DATA: u16 = 1 << 3;
249
250fn not_a_bundle(why: &'static str) -> BundleError {
251    BundleError::Zip(zip::result::ZipError::InvalidArchive(why))
252}
253
254/// Read a bundle's head off the front of the file, for a reader that has
255/// only that: a bundle behind a URL, asked for its first bytes.
256///
257/// A zip is read from its directory, which is at the end. The head is the
258/// first entry and stored, so its bytes are also right behind the first
259/// local header, and this reads that header by hand. It trusts what
260/// [`write_bundle`] wrote and nothing more general: any other first entry is
261/// not a bundle.
262pub fn head_from_front(front: &[u8]) -> Result<Front, BundleError> {
263    let Some(header) = front.get(..LOCAL_HEADER_LEN) else {
264        return Ok(Front::Needs(LOCAL_HEADER_LEN as u64));
265    };
266    if header[..4] != LOCAL_HEADER {
267        return Err(not_a_bundle("it does not begin with a zip entry"));
268    }
269    let short = |at: usize| u16::from_le_bytes([header[at], header[at + 1]]);
270    let long = |at: usize| {
271        u32::from_le_bytes([header[at], header[at + 1], header[at + 2], header[at + 3]])
272    };
273    let (flags, method, size) = (short(6), short(8), u64::from(long(22)));
274    let (name_len, extra_len) = (usize::from(short(26)), usize::from(short(28)));
275
276    let name_end = LOCAL_HEADER_LEN + name_len;
277    let Some(name) = front.get(LOCAL_HEADER_LEN..name_end) else {
278        return Ok(Front::Needs(name_end as u64));
279    };
280    if name != HEAD_ENTRY.as_bytes() {
281        return Err(BundleError::MissingEntry(HEAD_ENTRY));
282    }
283    if method != 0 || flags & SIZES_AFTER_DATA != 0 {
284        return Err(not_a_bundle("its head is not stored"));
285    }
286    let start = name_end + extra_len;
287    let end = start as u64 + size;
288    match usize::try_from(end)
289        .ok()
290        .and_then(|end| front.get(start..end))
291    {
292        Some(head) => parse_head(head).map(Front::Head),
293        None => Ok(Front::Needs(end)),
294    }
295}
296
297/// An open bundle: its manifest, decoded, and its blobs, still in the file.
298pub struct Bundle<R> {
299    manifest: Manifest,
300    archive: ZipArchive<R>,
301}
302
303/// Open a bundle and decode its manifest. A manifest that names a blob the
304/// bundle does not hold is refused here, not halfway through an install.
305pub fn open_bundle<R: Read + Seek>(reader: R) -> Result<Bundle<R>, BundleError> {
306    let mut archive = ZipArchive::new(reader)?;
307    // The head first: a bundle in another format is refused before its
308    // manifest is looked at.
309    parse_head(&read_entry(&mut archive, HEAD_ENTRY)?)?;
310    let manifest = parse_manifest(&read_entry(&mut archive, MANIFEST_ENTRY)?)?;
311    for id in manifest.blob_ids() {
312        if archive.index_for_name(&blob_entry(id)).is_none() {
313            return Err(BundleError::MissingBlob(id.to_owned()));
314        }
315    }
316    Ok(Bundle { manifest, archive })
317}
318
319impl<R: Read + Seek> Bundle<R> {
320    pub fn manifest(&self) -> &Manifest {
321        &self.manifest
322    }
323
324    pub fn into_manifest(self) -> Manifest {
325        self.manifest
326    }
327
328    /// Write the blob `id` to `out`, returning how many bytes that was.
329    pub fn copy_blob(&mut self, id: &str, out: &mut impl Write) -> Result<u64, BundleError> {
330        let mut entry = match self.archive.by_name(&blob_entry(id)) {
331            Ok(entry) => entry,
332            Err(zip::result::ZipError::FileNotFound) => {
333                return Err(BundleError::MissingBlob(id.to_owned()))
334            }
335            Err(other) => return Err(other.into()),
336        };
337        Ok(io::copy(&mut entry, out)?)
338    }
339}
340
341/// Hashes what passes through it, so a blob is checked against its name by
342/// the same read that writes it.
343struct Hashing<W> {
344    inner: W,
345    hasher: Sha256,
346}
347
348impl<W: Write> Write for Hashing<W> {
349    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
350        let written = self.inner.write(buf)?;
351        self.hasher.update(&buf[..written]);
352        Ok(written)
353    }
354
355    fn flush(&mut self) -> io::Result<()> {
356        self.inner.flush()
357    }
358}
359
360/// Every entry is written with the same fixed timestamp and mode, and blobs go
361/// in id order, so two builds of the same manifest are the same bytes.
362fn entry_options(method: CompressionMethod, size: u64) -> SimpleFileOptions {
363    SimpleFileOptions::default()
364        .compression_method(method)
365        .last_modified_time(DateTime::default())
366        .unix_permissions(0o644)
367        .large_file(size > u64::from(u32::MAX))
368}
369
370fn write_json<W: Write + Seek>(
371    zip: &mut ZipWriter<W>,
372    entry: &'static str,
373    method: CompressionMethod,
374    bytes: &[u8],
375) -> Result<(), BundleError> {
376    zip.start_file(entry, entry_options(method, bytes.len() as u64))?;
377    zip.write_all(bytes)?;
378    Ok(())
379}
380
381/// Write `manifest` and the blobs it names as a bundle, under `head`.
382///
383/// A head in another format is refused: this writes the one it reads.
384///
385/// Only blobs the manifest names are written — `blobs` may hold more, since an
386/// artifact a later plugin replaced leaves its blob behind. Each is hashed as
387/// it is copied and refused if it is not what its name says, which is the one
388/// moment a blob table can be caught lying.
389pub fn write_bundle<W: Write + Seek>(
390    writer: W,
391    head: &Head,
392    manifest: &Manifest,
393    blobs: &Blobs,
394) -> Result<(), BundleError> {
395    if head.format != BUNDLE_FORMAT {
396        return Err(BundleError::Format {
397            found: u64::from(head.format),
398        });
399    }
400    let json = |entry| move |source| BundleError::Json { entry, source };
401    let mut zip = ZipWriter::new(writer);
402
403    // Both are written to be read by a person: a bundle is debugged with
404    // `unzip -p`. The manifest is deflated, so its indentation costs about
405    // one percent of the entry.
406    let head = serde_json::to_vec_pretty(head).map_err(json(HEAD_ENTRY))?;
407    write_json(&mut zip, HEAD_ENTRY, CompressionMethod::Stored, &head)?;
408    let body = serde_json::to_vec_pretty(manifest).map_err(json(MANIFEST_ENTRY))?;
409    write_json(&mut zip, MANIFEST_ENTRY, CompressionMethod::Deflated, &body)?;
410
411    for id in manifest.blob_ids() {
412        let source = blobs
413            .get(id)
414            .ok_or_else(|| BundleError::MissingBlob(id.to_owned()))?;
415        let size = match source {
416            BlobSource::Bytes(bytes) => bytes.len() as u64,
417            BlobSource::File(path) => std::fs::metadata(path)
418                .map_err(|source| BundleError::BlobFile {
419                    id: id.to_owned(),
420                    path: path.display().to_string(),
421                    source,
422                })?
423                .len(),
424        };
425        zip.start_file(
426            blob_entry(id),
427            entry_options(CompressionMethod::Deflated, size),
428        )?;
429        let mut hashing = Hashing {
430            inner: &mut zip,
431            hasher: Sha256::new(),
432        };
433        match source {
434            BlobSource::Bytes(bytes) => hashing.write_all(bytes)?,
435            BlobSource::File(path) => {
436                let mut file = File::open(path).map_err(|source| BundleError::BlobFile {
437                    id: id.to_owned(),
438                    path: path.display().to_string(),
439                    source,
440                })?;
441                io::copy(&mut file, &mut hashing)?;
442            }
443        }
444        let found = hex::encode(hashing.hasher.finalize());
445        if found != id {
446            return Err(BundleError::BlobMismatch {
447                id: id.to_owned(),
448                found,
449            });
450        }
451    }
452
453    zip.finish()?;
454    Ok(())
455}