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